Skip to content

Conversation

@NagyZoltanPeter
Copy link

@NagyZoltanPeter NagyZoltanPeter commented Aug 24, 2025

This pr intends to unify all diverging TokenBucket implementations. waku-org/nwaku#3543
With different needs arising from nim-waku for DOS protection of req/resp protocol a slightly different implementation is added to nim-waku, copied and adopted from nim-chronos.
Now nim-chat-sdk has new requirements.
For to be future proof we decided to go back to the roots and fullfill all these needs in single place.
Also libp2p use was taken into account, so no interface is broken and expected token utilization is similar to original implementation.

What's done:

  • Original implementation of TokenBucket in nim-chronos has an issue (hence Short replenish test was failing in Ci and got skipped), when there were enough time passed between TokenBucket initialization and first update.
    Because the initial period is not calculated from the first consume but from init, could cause overuse of tokens.
    This bug is fixed here.
  • Waku implemented a different update methods.
    • There were a strict mode added, which allows only refill after full period passed, not in between. > This mode also needed in nim-chat-sdk
    • Other compensating mode was following a burst ballancing calculation, but different from original implementation, allowing overuse of tokens after silent period of time to some extent.
    • In this PR I revised this approach on both sides and refined the original implementation a bit to be smoother with small bursts within fill period and better utilize tokens compared to the possible available tokens during the given time frame. This will satisfy nim-waku compensating mode requirements also - thus no need to distinguish that with a third mode.
    • Now this Ballanced mode is the default for TokenBucket while user can decide to go for Strict replenish mode at creation.
  • nim-chat-sdk and nim-waku additional interface needs are added without compromising backward compatibility.
  • tests for TokenBucket are extended to cover all the changes while old ones kept to ensure backward compatibility.

In follow up PR may consider to move more rate limiting features from nim-waku to chronos...

Documentation and comparison to previous implementation

TokenBucket — Usage Modes (Overview)

TokenBucket provides several usage modes and patterns depending on how you want to rate-limit:

  • Continuous mode (default):

    • Mints tokens proportionally to elapsed time at a constant rate (capacity / fillDuration), adding only whole tokens.
    • When the bucket is full for an interval, the elapsed time is burned (no “credit banking”).
    • If an update would overfill, budget is clamped to capacity and leftover elapsed time is discarded; lastUpdate is set to the current time.
    • Nanosecond-level accounting for precise behavior.
  • Discrete mode:

    • Replenishes only after a full fillDuration has elapsed (step-like refill behavior).
    • Before the period boundary, budget does not increase; after the boundary, budget jumps to capacity.
    • Use when you need hard period boundaries rather than proportional accrual.
  • Manual-only replenish (fillDuration = 0):

    • Disables automatic minting; tokens can only be added via replenish(tokens).
    • Replenish is capped at capacity and wakes pending consumers.
  • Synchronous consumption: tryConsume(tokens, now)

    • Attempts to consume immediately; returns true on success, false otherwise.
    • If consuming from full, lastUpdate is set to now (prevents idle-at-full credit banking in Continuous mode).
  • Asynchronous consumption: consume(tokens, now) -> Future[void]

    • Returns a future that completes when tokens become available (or can be cancelled).
    • Internally, the waiter is woken around the time enough tokens are expected to accrue, or earlier if replenish() is called.
  • Capacity and timing introspection: getAvailableCapacity(now)

    • Computes the budget as of now without mutating bucket state.
  • Manual replenishment: replenish(tokens, now)

    • Adds tokens (capped to capacity), updates timing, and wakes waiters.

The sections below illustrate Continuous semantics with concrete timelines and compare them with the older algorithm for context.

TokenBucket Continuous Mode — Scenario 1 Timeline

Assumptions:

  • Capacity C = 10
  • fillDuration = 1s (per-token time: 100ms)
  • Start: t = 0ms, budget = 10, lastUpdate = 0ms

Legend:

  • Minted tokens: tokens added by Continuous update at that step (TA)
  • Budget after mint: budget after minting, before the consume at that row
  • Budget after consume: budget left after processing the request at that row
  • LU set?: whether lastUpdate changes at that step (reason)

Only request events are listed below (no passive availability checks):

Time Elapsed from LU Budget (in) Request tokens Minted tokens (TA) Budget after mint Budget after consume LU set?
0 ms n/a 10 7 0 10 3 yes (consume/full → 0 ms)
200 ms 200 ms 3 5 2 5 0 yes (update → 200 ms)
650 ms 450 ms 0 3 4 4 1 yes (update → 600 ms)
1200 ms 600 ms 1 6 6 7 1 yes (update → 1200 ms)
1800 ms 600 ms 1 5 6 7 2 yes (update → 1800 ms)
2100 ms 300 ms 2 10 3 5 5 (insufficient) yes (update → 2100 ms)
2600 ms 500 ms 5 10 5 (to cap) 10 (hit cap) 0 yes (update hit cap → 2600 ms); yes (consume/full → 2600 ms)

Notes:

  • When an update would overfill the bucket, it is clamped to capacity and lastUpdate is set to the current time; leftover elapsed time is discarded.
  • Consuming from a full bucket sets lastUpdate to the consume time (prevents idle-at-full credit banking).

Consumption Summary (0–3s window)

Per fillDuration period (1s each):

Period Requests within period Tokens consumed
0–1000 ms 0ms:7, 200ms:5, 650ms:3 15
1000–2000 ms 1200ms:6, 1800ms:5 11
2000–3000 ms 2100ms:10 (insufficient), 2600ms:10 (consumed) 10

Total consumed over 3 seconds: 15 + 11 + 10 = 36 tokens.

Old Algorithm (Pre-unification) — Scenario 1 Timeline

Reference: old TokenBucket from master (chronos/ratelimit.nim) before the unification.

Key behavioral differences vs current Continuous mode:

  • No LU reset when consuming from a full bucket (LU stays unchanged on consume).
  • No explicit burn of leftover elapsed time when capacity is reached; fractional leftover time is retained via LU based on minted tokens.

Assumptions and inputs are identical to the table above:

  • Capacity C = 10, fillDuration = 1s (100ms per token)
  • Request-only events at: 0ms (7), 200ms (5), 650ms (3), 1200ms (6), 1800ms (5), 2100ms (10), 2600ms (10)
  • Start: t = 0ms, budget = 10, lastUpdate = 0ms
Time Elapsed from LU Budget (in) Request tokens Minted tokens (TA) Budget after mint Budget after consume LU set?
0 ms n/a 10 7 0 10 3 no (consume does not set LU)
200 ms 200 ms 3 5 2 5 0 yes (update → 200 ms)
650 ms 450 ms 0 3 4 4 1 yes (update → 600 ms)
1200 ms 600 ms 1 6 6 7 1 yes (update → 1200 ms)
1800 ms 600 ms 1 5 6 7 2 yes (update → 1800 ms)
2100 ms 300 ms 2 10 3 5 5 (insufficient) yes (update → 2100 ms)
2600 ms 500 ms 5 10 5 (to cap) 10 (hit cap) 0 yes (update → 2600 ms)

Consumption Summary (0–3s window, old algorithm)

Period Requests within period Tokens consumed
0–1000 ms 0ms:7, 200ms:5, 650ms:3 15
1000–2000 ms 1200ms:6, 1800ms:5 11
2000–3000 ms 2100ms:10 (insufficient), 2600ms:10 (consumed) 10

Total consumed over 3 seconds (old): 36 tokens.

Diff Notes — Continuous vs Old

  • Consume from full:

    • Continuous: sets lastUpdate to the consume time before subtracting.
    • Old: does not modify lastUpdate on consume.
  • When the bucket is full during an elapsed interval:

    • Continuous: burns all the elapsed time by setting lastUpdate = currentTime (no hidden time credit).
    • Old: advances lastUpdate only by the time used to mint whole tokens, leaving fractional leftover time to carry to the next update.
  • Hitting capacity mid-update (overfill path):

    • Continuous: clamps to capacity and sets lastUpdate = currentTime, discarding leftover elapsed time.
    • Old: clamps to capacity but sets lastUpdate = lastUpdate + usedTime (time to mint the whole tokens), retaining the leftover fractional part of the elapsed interval.
  • Time resolution:

    • Continuous: nanosecond-based accounting.
    • Old: millisecond-based accounting. Sub-ms differences and rounding may diverge.

Practical impact:

  • Continuous avoids multi-call burst inflation at a single timestamp and prevents banking implicit credit during idle-at-full periods.
  • In this scenario with millisecond-aligned times, total consumption over 3 seconds is the same (36 tokens), but LU handling differs and becomes visible with sub-token leftover time, long idle while full, or overfill updates.

Comparison Examples

Case Inputs Continuous outcome Old outcome Key difference
Sub-token leftover time C=10, T=1s, budget=4, LU=1.000s; check at t=1.075s (Δ=75ms) PT=0 minted; budget stays 4; LU unchanged PT=0 minted; budget stays 4; LU unchanged No mint below one-token time; both unchanged (ns vs ms resolution doesn’t change the outcome)
Long idle while full C=5, T=1s, budget=5 (full), LU=0; next update at t=2.5s (Δ=2500ms) Budget remains 5; LU set to 2.5s (burn entire idle interval; no leftover fractional time) Replenished=floor(5×2.5)=12 (clamped); used=12×200ms=2400ms; budget=5; LU=2.4s (keeps 100ms leftover) Continuous burns idle-at-full time; Old retains fractional leftover (100ms)
Overfull update (hit capacity) C=10, T=1s, budget=8 (space=2), LU=0; update at t=300ms (Δ=300ms) PT=floor(3)=3; TA=min(3,2)=2; budget→10; LU=300ms (hit-cap path discards leftover 100ms) PT=3; TA=2; budget→10; used=2×100ms=200ms; LU=200ms (leftover 100ms retained) Continuous discards leftover on cap-hit; Old keeps leftover time

High-rate single-token requests (Continuous)

Settings:

  • Capacity C = 10, fillDuration = 10ms (per-token time: 1ms)
  • Window to observe: 0–40ms (4 full periods)
  • Requests are 1 token each; batches occur at specific timestamps.

We show how the bucket rejects attempts that exceed the available budget at each instant, ensuring no more than capacity + minted tokens are usable in any time frame. Over 0–40ms, at most 10 (initial capacity) + 4 × 10 (mint) = 50 tokens can be consumed.

Request batches and outcomes:

Time Elapsed from LU Budget before Minted (PT→TA) Budget after mint Requests (×1) Accepted Rejected Budget after consume LU after
0 ms n/a 10 0 10 12 10 2 0 0 ms
5 ms 5 ms 0 5 → 5 5 7 5 2 0 5 ms
10 ms 5 ms 0 5 → 5 5 15 5 10 0 10 ms
12 ms 2 ms 0 2 → 2 2 3 2 1 0 12 ms
20 ms 8 ms 0 8 → 8 8 25 8 17 0 20 ms
30 ms 10 ms 0 10 → 10 10 9 9 0 1 30 ms
31 ms 1 ms 1 1 → 1 2 3 2 1 0 31 ms
40 ms 9 ms 0 9 → 9 9 20 9 11 0 40 ms

Totals over 0–40ms:

  • Attempted: 12 + 7 + 15 + 3 + 25 + 9 + 3 + 20 = 94 requests
  • Accepted: 10 + 5 + 5 + 2 + 8 + 9 + 2 + 9 = 50 tokens (matches 10 + 4×10)
  • Rejected: 94 − 50 = 44 requests

Why the rejections happen (preventing overuse):

  • At any given instant, you can only consume up to the tokens currently in the bucket.
  • Between instants, tokens mint continuously at capacity / fillDuration = 1 token/ms; the table shows how many become available just before each batch.
  • When a batch demands more than available, the excess is rejected (or would be queued with consume()), enforcing the rate limit.
  • Over any observation window, the maximum consumable tokens = initial available (up to capacity) + tokens minted during that window; here, that cap is 10 + (40ms × 1/ms) = 50.

Copy link

@Ivansete-status Ivansete-status left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks for such a master piece PR! 🙌
I'm just adding a few nits that I hope you find useful

@NagyZoltanPeter
Copy link
Author

cc: @richard-ramos I would be happy if you would look at it with eye of libp2p. Thx.

@arnetheduck
Copy link
Member

Great to see, though this needs a security review from a nimbus-eth2 perspective since it's on the critical security path there - let's hold off merging until that's done

Copy link
Member

@arnetheduck arnetheduck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs a draft eth2 PR including the changes, along with a security review of its impact on that codebase

@NagyZoltanPeter
Copy link
Author

Needs a draft eth2 PR including the changes, along with a security review of its impact on that codebase

Doing that! Thanks for the point!

@NagyZoltanPeter
Copy link
Author

Great to see, though this needs a security review from a nimbus-eth2 perspective since it's on the critical security path there - let's hold off merging until that's done

@arnetheduck: status-im/nimbus-eth2#7436

@arnetheduck
Copy link
Member

compensating

is this described in literature / papers somewhere?

@NagyZoltanPeter
Copy link
Author

compensating

is this described in literature / papers somewhere?

No, it's not.
Actually, this algorithm is named as compensating (as opposed to strict mode) does not differ much from the original implementation replenising algorithm. Both tries to compensate lower rate periods within time to achieve a better utilization of tokens within time periods.
In nwaku and chat-sdk we need the token bucket function with a clear cut in usage of tokens within the given time period.

If we need such a "paper" I can write a spec or something similar discussing these replenishing algorithms. Please suggest the right place for it.

@arnetheduck
Copy link
Member

Please suggest the right place for it.

The docs of the code would probably be a good place to start, or if it's more complex than that, the chronos book - from the description, "better utilization" doesn't really explain what's going on and "minting tokens based on elapsed time" sounds a bit like it could violate the upper bound condition in which case it's no longer a token bucket.

Instead, it would be better to formalize the precise behavior of the modes, ie how they behave mathematically - then it's up to the consumer to decide which behavior is "better" (ie "better utilization" is not something you put in the docs without quantifying what "better" means).

Basically, the parameters you're looking to control with a token bucket are:

  • the rate - ie the average number of items per time unit
  • the burst size - the maximum number of items that can be taken out of the bucket at any given time as a debt towards the rate - key for this limit is that it never grows bigger than the upper bound of the bucket

A consequence of the above design is that you can de facto never take out a batch larger than bucket bound - ie if your bucket holds 100 items and you want to send 101, you're stuck - you could argue that in this case, the bucket should wait until it has accumulated space for 101 items, ie it should behave exactly as if someone requested 100 items then 1 item (or conversely 1 item then 100 items) - as long as these invariants are maintained - ie as long as under all conditions, the behavior reduces to the description in the literature if only we split up the requests, we're fine and we don't need "modes".

If it doesn't reduce to the above behavior, it's no longer a token bucket.

Now, there are some nuances here that one could argue about:

  • When you construct the bucket, does it come with items "available" - ie at time 0, can I take out 100 items in a 1 item/s bucket with a limit of 100? (probably yes)
  • if I take out 1 item, do I have 99 items left in the burst or must the bucket be empty in order to make a new burst request? (probably, you have 99 items left for a general-purpose bucket)
  • if I request more than the upper limit, should the bucket "automatically" behave as if multiple, smaller requests have been made to it, and wait? probably yes - we are after all designing a "waiting" bucket - the alternative is a "discarding" bucket which drops the request
  • If I request more than the upper limit and time has passed before the request such that more than the upper bound has acumulated, should you be allowed to take it all out in a burst? Ie if I wait 200s in the 1 item/s case, can I take out 200 items without waiting? No, this violates the 100-item upper bound.

@arnetheduck
Copy link
Member

Another way to reframe the above invariant: there should be no period of time [T, T+replenish_time) where the amount of issued tokens exceeds the burst limit - where replenish_time is a function of rate and burst size.

I think it's useful to document the behavior with respect to these invariants - and then we can compare the two approaches and find gaps in either where they would be exploitable.

…ment algorithm, extended TokenBucket unit test
@NagyZoltanPeter
Copy link
Author

Please suggest the right place for it.

The docs of the code would probably be a good place to start, or if it's more complex than that, the chronos book - from the description, "better utilization" doesn't really explain what's going on and "minting tokens based on elapsed time" sounds a bit like it could violate the upper bound condition in which case it's no longer a token bucket.

Instead, it would be better to formalize the precise behavior of the modes, ie how they behave mathematically - then it's up to the consumer to decide which behavior is "better" (ie "better utilization" is not something you put in the docs without quantifying what "better" means).

Basically, the parameters you're looking to control with a token bucket are:

  • the rate - ie the average number of items per time unit
  • the burst size - the maximum number of items that can be taken out of the bucket at any given time as a debt towards the rate - key for this limit is that it never grows bigger than the upper bound of the bucket

A consequence of the above design is that you can de facto never take out a batch larger than bucket bound - ie if your bucket holds 100 items and you want to send 101, you're stuck - you could argue that in this case, the bucket should wait until it has accumulated space for 101 items, ie it should behave exactly as if someone requested 100 items then 1 item (or conversely 1 item then 100 items) - as long as these invariants are maintained - ie as long as under all conditions, the behavior reduces to the description in the literature if only we split up the requests, we're fine and we don't need "modes".

If it doesn't reduce to the above behavior, it's no longer a token bucket.

Now, there are some nuances here that one could argue about:

  • When you construct the bucket, does it come with items "available" - ie at time 0, can I take out 100 items in a 1 item/s bucket with a limit of 100? (probably yes)
  • if I take out 1 item, do I have 99 items left in the burst or must the bucket be empty in order to make a new burst request? (probably, you have 99 items left for a general-purpose bucket)
  • if I request more than the upper limit, should the bucket "automatically" behave as if multiple, smaller requests have been made to it, and wait? probably yes - we are after all designing a "waiting" bucket - the alternative is a "discarding" bucket which drops the request
  • If I request more than the upper limit and time has passed before the request such that more than the upper bound has acumulated, should you be allowed to take it all out in a burst? Ie if I wait 200s in the 1 item/s case, can I take out 200 items without waiting? No, this violates the 100-item upper bound.

@arnetheduck
This is a very valid point, so I added a ratelimit.md to the docs and hope it is clear to show the expected way it works.
Also added calculation in comparison to the original TokenBucket implementation about replenishing result in different scenarios and it come out with same amount of token consumption in both cases. The only real difference is about last update time handling and how minting is done.
Also extended the test cases with relevant scenarios.
Please re-review if that satisfy.

@NagyZoltanPeter
Copy link
Author

@arnetheduck gentle urging here, please let me know if the added explanation and doc are satisfying? Thank you.


if currentTime < bucket.lastUpdate:
return
return (bucket.budgetCapacity, currentTime)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will introduce a drift since the current time likely will not land on a fillDuration boundary .. the net result of the drift will be a lower average throughput, even if updates happen on a regular bases (ie at least once per fill duration).

What you're looking to return here is max(bucket.lastUpdate + bucket.fillDuration, currentTime - fillDuration) or something like that to ensure that all tokens are handed out correctly.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got your point, but with given this calculation is for Discrete mode I think we need a calculation of ditinct replenist periods elapsed since lastUpdate, because the replanish might not happen that often. That we we can be precise with fillDuration boundaries. WDYT?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

precise with fillDuration boundaries

This is only meaningful if the initial "update tick" is set to a specific value. However, the downside of this approach is that you again reduce the effective rate - another way to describe this approach is that you're rounding the last update up to the next fillDuration boundary, and this rounding is effectively "time that no tokens are created".

…w algo comparison from documentation, fix Discrete mode update calculation to properly calculate correct last update time by period distance calculation.
Copy link
Author

@NagyZoltanPeter NagyZoltanPeter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the comments @arnetheduck. I addressed your findings.


if currentTime < bucket.lastUpdate:
return
return (bucket.budgetCapacity, currentTime)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got your point, but with given this calculation is for Discrete mode I think we need a calculation of ditinct replenist periods elapsed since lastUpdate, because the replanish might not happen that often. That we we can be precise with fillDuration boundaries. WDYT?

if bucket.budget >= tokens:
# If bucket is full, consider this point as period start, drop silent periods before
if bucket.budget == bucket.capacity:
bucket.lastUpdate = now
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does this change the behavior?

replenishMode: ReplenishMode

proc update(bucket: TokenBucket, currentTime: Moment) =
func periodDistance(bucket: TokenBucket, currentTime: Moment): float =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a better factoring would be to have an elapsed(bucket, now) function that returns a Duration, that would be used for both discrete and continuous mode - it would clamp the elapsed time to values >= 0 which is what both modes do.

replenishMode: replenishMode
)

proc setState*(bucket: TokenBucket, budget: int, lastUpdate: Moment) =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function would have to take into account waiting requests for budget - basically, setting the values here is a special version of a manual replenish and needs to be treated as such to maintain internal consistency. This begs the question what the use case is that isn't covered by a manual replenish?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants